Skip to content

feat(mcp): limit, having and a default order for clickstack_table - #3558

Merged
kodiakhq[bot] merged 4 commits into
ClickHouse:mainfrom
bcanvural:feat/mcp-table-limit-having-order
Nov 4, 2026
Merged

kodiakhq[bot] merged 4 commits into
ClickHouse:mainfrom
bcanvural:feat/mcp-table-limit-having-order

Conversation

@bcanvural

Copy link
Copy Markdown
Contributor

Closes #3555

What

MCP surface only; dashboard tiles and the app don't change.

  • limit (optional, positive integer): SQL LIMIT, applied after ordering. No default, so a call without it returns the same rows as today.
  • having (optional): post-aggregation SQL HAVING. It can use a select item's alias ("Errors > 10") or repeat the aggregate ("count() > 100").
  • Default order: with a groupBy and no orderBy, a table result is ordered by the first select item, descending. Today the order is whatever ClickHouse returns, so response trimming keeps arbitrary groups. An explicit orderBy is used exactly as before.

How:

  • The default lives in the tool (defaultOrderBy in packages/api/src/mcp/tools/query/table.ts), next to resolveOrderBy, not in renderChartConfig. It uses the first item's alias when it has one, otherwise its position (ORDER BY 1 DESC): unaliased select items have no name to refer to, and the rendered SELECT lists the select items before the groupBy columns. It is skipped for metric selects (their SQL is built from CTEs, and increase already orders its top-N groups) and for the number/pie/bar shapes.
  • having and limit reach the chart config through two new optional runConfigTile options, set only by clickstack_table. The table tile schema has no limit, and pie/bar tiles have no having, so passing them through the tile would drop them for some shapes; the options apply to every shape and leave the tile schemas alone. Other callers of runConfigTile don't pass them, so nothing changes for them.

Tests

  • query.test.ts: defaultOrderBy (position vs alias, quoted alias, no default without groupBy, for other shapes, or for metric selects).
  • queryTool.int.test.ts, against ClickHouse with 3 services of 1/5/3 rows: default order with an alias and by position, explicit orderBy unchanged, limit after ordering, having on an alias.
  • API MCP unit tests: 487 pass. queryTool.int.test.ts: 95 pass. tsc --noEmit, eslint (no new warnings) and prettier are clean.

Not included: unknown input keys are still stripped silently; that's #3543.

clickstack_table had no limit or having, and without orderBy a grouped
result came back in arbitrary order, so trimming kept arbitrary groups.
Add optional limit and having (passed into the chart config through
runConfigTile options, MCP only), and order grouped tables by the first
select item, descending, when no orderBy is given. Dashboard tiles and the
app are unchanged. Refs ClickHouse#3555.
@bcanvural
bcanvural requested a review from a team as a code owner November 4, 2026 18:51
@vercel

vercel Bot commented Nov 4, 2026

Copy link
Copy Markdown

@bcanvural is attempting to deploy a commit to the HyperDX Team on Vercel.

A member of the Team first needs to authorize it.

@changeset-bot

changeset-bot Bot commented Nov 4, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: e778e2a

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 3 packages
Name Type
@hyperdx/api Patch
@hyperdx/app Patch
@hyperdx/otel-collector Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

* built from CTEs, and `increase` already orders its top-N groups.
*/
/** @internal Exported for testing only. */
export function defaultOrderBy(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 minor — defaultOrderBy re-implements the default ordering in convertToCategoricalChartConfig, without its tiebreak

convertToCategoricalChartConfig (packages/common-utils/src/core/utils.ts:1070-1097) already adds a default 'first select item DESC' order. It quotes the alias, or sets it to 'Value' when there is none, and adds the groupBy ASC as a stable tiebreak. defaultOrderBy does the same job a second way (positional 1, no tiebreak). So with limit, groups that tie on the first value can come back differently on each call. Pull the shared logic into one common-utils helper and use it for both, or at least add the groupBy ASC tiebreak here so limited results are deterministic.

Advisory. Fix if it is a small defect in code this PR changes; otherwise reply in the thread. Do not widen the PR or touch files it did not already change. How to respond

'Reference a select item by its alias (e.g. "Errors > 10") or repeat the aggregate.',
),
orderBy: orderBySchema.describe(
'Sort results by this column. Defaults to the first select item, descending, ' +

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 minor — orderBy description promises a default order that metric sources and pie/bar shapes don't get

The schema text says results default to the first select item, descending, whenever groupBy is set. But defaultOrderBy returns undefined for metric selects and for every shape except 'table'. Pie/bar only get a default order through convertToCategoricalChartConfig, and only when a limit is set. An agent querying a grouped metric table will expect ordered, top-N-safe results and get arbitrary order after trimming. Narrow the description (e.g. 'for non-metric sources with shape "table"'), or extend the default to cover those cases.

Advisory. Fix if it is a small defect in code this PR changes; otherwise reply in the thread. Do not widen the PR or touch files it did not already change. How to respond

const tableToolOverrides = isSearch
? {}
: {
...(options?.having

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 minor — A whitespace-only having passes the truthiness check

options?.having ? … lets a value like ' ' through, so it gets set on the chart config with havingLanguage 'sql'. The having schema in table.ts has no .trim() or .min(1). Add .trim() to the schema (or check options.having.trim()) so an empty condition is dropped rather than depending on the renderer's isNonEmptyWhereExpr guard.

Advisory. Fix if it is a small defect in code this PR changes; otherwise reply in the thread. Do not widen the PR or touch files it did not already change. How to respond

@github-actions

github-actions Bot commented Nov 4, 2026 •

Copy link
Copy Markdown
Contributor

PR Review

✅ No issues found.

@github-actions

github-actions Bot commented Nov 4, 2026 •

Copy link
Copy Markdown
Contributor

Deep Review

✅ No critical issues found. No P0/P1: having is the same authorized read-only SQL surface as the existing where (ClickHouse readonly=2, teamId-scoped source/connection), error paths are caught, and the runConfigTile change is a no-op for every non-table caller. The notes below are coverage and documentation recommendations.

🟡 P2 -- recommended

  • packages/api/src/mcp/tools/query/helpers.ts:592 -- having/limit are applied to every non-search shape (number, pie, bar), yet the field descriptions frame them as grouping-only, so an agent using shape: "number" with a having can get an unexpectedly empty result that the docs ("applied after grouping", "Omit to return every group") don't predict.
    • Fix: Restrict having/limit to shape table, or amend the descriptions to state the cross-shape behavior explicitly.
    • correctness, api-contract
  • packages/api/src/mcp/__tests__/queryTool.int.test.ts:434 -- having is passed straight through as raw SQL but only the happy path is tested; a malformed or unresolvable having has no assertion that the tool surfaces a graceful isError instead of an unhandled failure.
    • Fix: Add an integration case feeding an invalid having (e.g. "nope > 1") and assert result.isError is set, mirroring the existing bad-select-column error test.
    • testing, adversarial
  • packages/api/src/mcp/__tests__/queryTool.int.test.ts:434 -- limit/having on metric sources (CTE-based SQL, where defaultOrderBy intentionally skips ordering) are only covered at the defaultOrderBy unit level, not end to end, so a dropped clause or alias-resolution error on the metric path would not be caught.
    • Fix: Add an integration test passing limit (with an explicit orderBy, per the schema note) and having against a metric source.
    • correctness, testing, project-standards, kieran-typescript
🔵 P3 nitpicks (5)
  • packages/api/src/mcp/tools/query/table.ts:70 -- a bare aggregate in having (e.g. "count() > 100") silently ignores the top-level where because the filter is folded into per-item aggIf conditions, producing a semantically wrong result rather than an error; this is already documented in the description but remains a footgun.
    • Fix: Optionally detect a bare aggregate call in having and return an mcpUserError nudging the agent to reference the select alias.
  • packages/api/src/mcp/tools/query/table.ts:239 -- defaultOrderBy reimplements the DESC-first-select + groupBy-ASC tiebreak that convertToCategoricalChartConfig already produces for pie/bar, with divergent quoting/positional details, so the two can silently drift apart.
    • Fix: Document the shared tiebreak contract in one place (a constant or cross-referenced comment) so both sites are updated together.
  • packages/api/src/mcp/tools/query/helpers.ts:592 -- the generic runConfigTile now carries table-tool-specific having/limit options and a tableToolOverrides branch whose name bakes one caller into a shared helper, and the comment explains where the values come from but not why they bypass the tile while orderBy/groupBy go through buildTile.
    • Fix: Rename tableToolOverrides to describe the behavior and note the real reason the override path was chosen over setting the fields on the tile.
  • packages/api/src/mcp/tools/query/table.ts:240 -- defaultOrderBy re-declares an ad-hoc { alias?: string; metricType?: string }[] subset of McpSelectItem and repeats the displayType union literal already written at the call site.
    • Fix: Use Pick<McpSelectItem, 'alias' | 'metricType'>[] and a shared displayType union alias.
  • packages/api/src/mcp/__tests__/query.test.ts:1433 -- defaultOrderBy builds "1 DESC, a, b ASC" for a multi-column groupBy, applying the ASC keyword only to the last column, but every unit and integration case uses a single groupBy column so the multi-column tiebreak is unpinned.
    • Fix: Add a defaultOrderBy case with a comma-separated groupBy to lock in the intended tiebreak SQL.

Reviewers (9): correctness, adversarial, security, api-contract, testing, kieran-typescript, maintainability, previous-comments, project-standards.

Testing gaps:

  • Error path for malformed having is unasserted (raw SQL passthrough).
  • limit/having on metric sources and on number shape are not exercised at the integration level.
  • Multi-column groupBy default-order tiebreak, bare-aggregate having, and the explicit-orderBy-plus-limit combination are untested.

No finding blocks merge automatically. A maintainer will expect P0/P1 findings in code this PR changes to be fixed; P2/P3 are your call -- fix or reply. Never fix findings about surrounding code here; reply instead. Do not widen the PR. How to respond

…By/having descriptions, trim having

Add the groupBy columns as an ASC tiebreak after the default first-item DESC,
as convertToCategoricalChartConfig does, so a limit keeps the same groups.
Say the default applies to shape "table" on non-metric sources, recommend
referencing select aliases in having, and trim having so a blank value is
dropped.
@bcanvural

Copy link
Copy Markdown
Contributor Author

Thanks for the reviews. Addressed in e3f65a1 (all minor):

  • Default order now adds the groupBy columns as an ASC tiebreak after the first item DESC, as convertToCategoricalChartConfig does, so a limit keeps the same groups on every call.
  • orderBy description: the default applies to shape "table" on non-metric sources with a groupBy.
  • having: trimmed, so a blank value is dropped; the description recommends a select alias, since a bare aggregate such as count() ignores the top-level where.
  • JSDoc merged into one block.

Not changed: a shared helper with convertToCategoricalChartConfig (that would touch the app path, which #3555 asked to keep as is), and the positional ORDER BY 1 for unaliased items (pinned by the integration test; ClickHouse enables positional arguments by default). No upper bound on limit, since response trimming and max_execution_time already cap it; happy to add one if you prefer.

Unit (488) and the query tool integration tests (95) pass.

if (selectItems.length === 0 || selectItems.some(s => s.metricType)) {
return undefined;
}
const alias = selectItems[0].alias?.trim();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 minor — Default ORDER BY trims the alias, but SELECT keeps the untrimmed alias, so the ORDER BY names a column that doesn't exist

mcpSelectItemSchema.alias in schemas.ts isn't trimmed, and renderSelectList emits it verbatim as AS " Errors" (packages/common-utils/src/core/renderChartConfig.ts:814-815). defaultOrderBy trims it first and emits Errors DESC, which ClickHouse can't resolve. Before this PR, a grouped call with that alias and no orderBy worked; now it fails. Fix: build the ORDER BY from the raw alias, i.e. quoteAliasForOrderBy(selectItems[0].alias), and only use .trim() to check that the alias isn't empty. Alternatively, trim alias in the schema so SELECT and ORDER BY see the same value.

Advisory. Fix if it is a small defect in code this PR changes; otherwise reply in the thread. Do not widen the PR or touch files it did not already change. How to respond

…est limit/having on pie and bar; cap limit

Quote the first select alias untrimmed so ORDER BY names the rendered column.
Add integration tests for limit and having on the pie/bar shapes (through
convertToCategoricalChartConfig) and for a quoted alias with a space. Cap
limit at 10,000, say metric sources need an explicit orderBy with limit, and
type defaultOrderBy's displayType as the shape union.
@bcanvural

Copy link
Copy Markdown
Contributor Author

Round 2, in the latest commit:

  • 🔵 Default order with an alias that has surrounding spaces: the alias is now quoted exactly as SELECT renders it (trim only checks for empty); unit test added.
  • P2 (round 1, which I missed in my last reply): limit/having on the pie/bar shapes, which go through convertToCategoricalChartConfig. Integration tests added: limit keeps the largest groups and having filters on both shapes. No code change was needed; the categorical conversion keeps our limit and adds its own value-descending order.
  • P2: quoted alias with a space, end to end: integration test for the default order and an explicit orderBy: '"Line Count" ASC'.
  • P2: limit on metric sources has no default order; the limit description now says to set orderBy with it there.
  • P3: limit capped at 10,000 (clean validation error instead of an Int32 binding error), having description says to alias the select item and reference it, displayType typed as the shape union.

Not changed: moving the having/limit schemas into schemas.ts (refactor beyond this PR's scope).

Unit (489) and query tool integration tests (100) pass. That's two rounds, so per AGENTS.md I'll stop changing things here. @brandon-pereira, ready for a look when you have time.

@brandon-pereira brandon-pereira left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM - thanks again!

@kodiakhq
kodiakhq Bot merged commit a13c80e into ClickHouse:main Nov 4, 2026
25 of 27 checks passed
kodiakhq Bot pushed a commit that referenced this pull request Nov 7, 2026
…ordered reductions, trim notes (#3554)

Refs #3542

Follows the proposal in the maintainer comment on #3542 (the three points, in order). Kept to `trimToolResponse`; the table tool's `having` / `limit` will go to a separate issue and PR as asked.

## What

1. **Shorten instead of replace** (`packages/api/src/utils/trimToolResponse.ts`). A string over its budget is cut and gets a `…(+N chars)` suffix (e.g. `…(+6,900 chars)`), instead of becoming `{"__hdx_trimmed":true,"originalSize":N}`. This applies to every tool through the fallback. In arrays, items within an equal share stay whole and the large items split what's left, so shortened items add up to the array's budget (with replacement markers that didn't matter; with shortened strings it does).
2. **Trim notes.** `TrimResult` gains `notes: string[]`: the size before/after and limit, each reduction that changed something (e.g. `patterns[].samples: kept the first 1 (60 elements removed from 30 items)`), arrays cut by path (`patterns: cut from 30 to 10 items`), and how many values were shortened. Every caller this PR changes that already adds a `note` when trimmed now also returns them as `trimNotes`; `clickstack_describe_table` is not changed here.
3. **Ordered reductions.** `trimToolResponse(data, maxSize, { reductions })` with `{ path, keep }`, `{ path, maxChars }` and `{ path, drop: true }`, using the path syntax from the proposal (`patterns[].samples`, `patterns[].samples[]`). It works as described:
   - a response that fits is returned unchanged (same object);
   - otherwise the reductions run in order on a copy, each on every match, and the whole response is measured after each step; it stops as soon as it fits;
   - only then the existing per-key split and array cuts run.

   `clickstack_event_patterns` uses the list from the proposal (samples keep 3, keep 1, samples[] maxChars 2000, pattern maxChars 2000, trend drop), plus a last step: `patterns[].whereSnippet` is dropped, never shortened, because a cut drill-down predicate is invalid SQL. Since it's dropped before the fallback runs, the fallback (the only part that shortens strings) never sees one; the `note` says when snippets were removed. `clickstack_emerging_signals` shortens `emerging[].pattern` / `disappeared[].pattern` to 2,000 chars first.
4. **Smaller fixes from review.** `shortenString` never makes a value longer and doesn't split surrogate pairs; array item budgets count the separators; array cuts are noted once per path and size markers are noted; note paths match the response (`result.…`); `search` with `denoise` keeps the first trim's notes; the `clickstack_sql` batch appends its row cut to the per-query notes.

## Effect

An `event_patterns`-shaped result with 30 patterns, the top 3 being ~4.5 KB stack traces with 5 samples each (101,450 chars, limit 50,000):

| | Patterns returned | Top pattern | Size |
|---|---|---|---|
| `main` | 10 | `{"__hdx_trimmed":true,"originalSize":4736}` | 3,848 |
| Fallback only (this PR, no reductions) | 10 | shortened, with `…(+N chars)` | 8,282 |
| With the event_patterns reductions | **30** | complete | 41,082 (stopped after "samples keep 1") |

## Tests

`packages/api/src/utils/__tests__/trimToolResponse.test.ts` (12 new):
- suffix format; shortened (not replaced) strings in the fallback within the budget; notes for size, shortened values and array cuts; no notes when nothing is trimmed;
- each reduction kind on every match, with its note; stop-early (later steps don't run once it fits); unchanged when it fits; input not mutated; fallback after reductions stays within the budget;
- the 30-pattern case above: all 30 kept, no `__hdx_trimmed`.

- JSON-escape expansion (quotes, control chars, non-ASCII, `…`) stays within the budget; separators-only arrays; no-growth and surrogate cases; grouped notes; `drop` on array elements.

`packages/api/src/mcp/__tests__/queryTool.int.test.ts`: the real `event_patterns` tool with 30 large patterns keeps all 30, drops (never cuts) `whereSnippet`, and returns `trimNotes`. `sqlToolBatch.test.ts`: batch cuts replace `trimNotes`.

`npx jest src/mcp src/utils --testPathIgnorePatterns '\.int\.test\.ts$'`: 932 passed; `queryTool` + `emergingSignalsTool` int tests: 117 passed. tsc, eslint (no new warnings) and prettier clean on the changed files.

## Not included

- `having` / `limit` / default order for `clickstack_table`: separate issue and PR (#3555, #3558), as asked.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

clickstack_table: no limit, having or default order, so large grouped results come back trimmed in arbitrary order

2 participants